3.6 Organización de datos con el tidyverse
3.6.1 El tidyverse y su flujo de trabajo
El tidyverse es, según se define en su propia página web,12 un conjunto de paquetes de R “opinables” diseñados para ciencia de datos. Las principales ventajas (opinables) de utilizar el tidyverse son tres:
Utiliza una gramática, estructuras de datos y filosofía de diseño común.
El flujo de trabajo es más fluido y, una vez se comprenden las ideas principales, más intuitivo.
Para la mayoría de las operaciones, es computacionalmente más eficiente.
Uno de los paquetes más populares del tidyverse es ggplot2, que proporciona una “gramática de gráficos” (Wickham, 2016) y es una pieza clave del tidyverse actual, junto con los paquetes dplyr (gramática para la manipulación de datos) y tidyr (herramienta para crear datos tidy). El flujo de trabajo propuesto por el tidyverse se describe en el libro R for Data Science (Wickham & Grolemund, 2016) y se sintetiza en la Fig. 3.1.
Además del mencionado libro, la web del tidyverse (http://tidyverse.org) contiene toda la documentación de los paquetes, incluidos artículos para tareas concretas, que merece la pena leer alguna vez. En la web están también las conocidas como cheatsheets, algunas de ellas disponibles también en la ayuda de RStudio (menú Help/Cheatsheets).
Dentro del flujo de trabajo de la Fig. 3.1, ya se ha tratado la primera etapa (Import) en la Sec. 3.5.2. Es importante señalar que, al utilizar las funciones del tidyverse, los datos se organizan en objetos de clase tibble, que es una extensión del data.frame de R base. Las principales diferencias son:
Permite una representación compacta en la consola al mostrar la tabla de datos.
La selección con corchetes simples de una única variable siempre devuelve otro
tibble(a diferencia de undata.frame, que devuelve un vector).
Se puede forzar a que una tabla de datos sea de un tipo u otro con las funciones as.data.frame (de tibble a data.frame) y as_tibble (de data.frame a tibble).
Siguiendo con el esquema de la Fig. 3.1, en este apartado se verán algunas tareas de las etapas Tidy (organizar) y Transform (transformar), que serán ampliadas en los Cap. 8 y 9. La visualización (Visualise) se tratará específicamente en el Cap. 11 y transversalmente en muchos otros. La modelización (Model) se trata extensamente en los capítulos de las partes IV a IX, y la comunicación (Communicate) se verá en los capítulos de la Parte X. Una de las características de la forma en que están programados los paquetes del tidyverse es que se puede trabajar13 con pipes.
El pipe es, básicamente, un operador compuesto de dos caracteres, |>, que se puede obtener con el atajo de teclado CTRL+MAYUS+M. El operador se pone en medio de dos expresiones de R, sean lado_izquierdo y lado_derecho las expresiones que se ponen a izquierda y derecha del pipe. Entonces se utiliza de la siguiente manera:
lado_izquierdo |> lado_derechoLa expresión lado_izquierdo debe producir un valor, que puede ser cualquier objeto de R. La expresión lado_derecho debe ser una función, que tomará como primer argumento el valor producido en la parte izquierda. Si se desea guardar el resultado final, se debe asignar el resultado a algún nombre de objeto para que se almacene en el espacio de trabajo. La siguiente expresión sería un ejemplo de uso.
nombre_objeto <- lado_izquierdo |>
lado_derechoLa ventaja de usar los pipes es que se pueden encadenar, de forma que el resultado de cada operación pasa a la siguiente expresión del pipeline (secuencia de operaciones con pipe), como en el siguiente ejemplo:
library("dplyr")
contam_mad |> colnames() |> length()
#> [1] 123.6.2 Transformación de datos con dplyr
En la gramática del tidyverse, dentro del paquete dplyr se dispone de una serie de “verbos” (funciones) para una sola tabla, que se pueden agrupar en tres categorías: para trabajar con filas, para trabajar con columnas y para resumir datos.
3.6.2.1 Operaciones con filas
Los verbos definidos para estas operaciones son:
filter(): elige filas en función de los valores de la columna.pm10 <- contam_mad |> filter(nom_abv == "PM10") # se filtra por PM10arrange(): cambia el orden de las filas con algún criterio.zonas<- contam_mad |> arrange(desc(zona), daily_mean)slice(): extrae filas por su índice. También hay una serie de funciones “asistentes” (helpers) para obtener los índices que se utilizan con frecuencia. Por ejemplo:slice_head()yslice_tail()obtienen las primeras y últimas filas respectivamente (por defecto, una). Se puede especificarn(número) oprop(proporción) de filas.slice_sample()obtiene una muestra aleatoria denfilas (o proporciónprop).slice_min(),slice_max()obtienen las filas que contienen los menores o mayores valores respectivamente de la variable indicada en el argumentoorder_by. Si no se especificanoprop, se obtienen solo las filas que contienen el mínimo o el máximo. Nótese que puede haber más de una fila que cumpla la condición.
Véase el resultado de los siguientes ejemplos:
pm10 |> slice(10:15) # extrae filas desde la 10 a la 15
pm10 |> slice_tail(n = 3) # extrae las tres últimas filas
pm10 |> slice_max(order_by = daily_mean) # día con mayor valor medio de PM10
set.seed(1) # Para que la muestra aleatoria sea reproducible
pm10 |> slice_sample(n = 4) # muestra 4 registros3.6.2.2 Operaciones con columnas
Los verbos definidos para estas operaciones son:
select(): indica cuando una columna se incluye o no. Se pueden utilizar helpers para seleccionar columnas que cumplan cierta condición (por ejemplo, ser numéricas) y también para “quitar” columnas de la selección (con el signo menos, [-]).pm10 |> select(longitud, latitud, daily_mean, tipo) pm10 |> select(where(is.numeric)) pm10 |> select(-c(id:latitud))
En cuanto a la modificación de datos, existen múltiples posibilidades. Algunas de ellas son:
rename(): cambia el nombre de la columna.mutate(): cambia los valores de las columnas y crea nuevas columnas. La funcióntransmute()funciona igual quemutate(), pero la tabla de datos resultante solo contiene las nuevas columnas creadas.relocate(): cambia el orden de las columnas.
pm10 |> rename(zona_calidad_aire = zona)
pm10 |> relocate(fecha, .before = estaciones)
pm10_na <- pm10 |> mutate(isna = is.na(daily_mean))En este punto, es importante señalar que dentro de la función mutate() se puede usar cualquier función vectorizada para transformar las variables. Por ejemplo, se podría transformar una columna con las funciones as.xxx que se vieron en la Sec. 3.5.1, aplicar formatos a fechas o usar funciones del paquete lubridate para trabajar con este tipo de datos. A medida que se avance en el libro irán apareciendo aplicaciones que ahora, quizás, no sean tan evidentes.
3.6.2.3 Operaciones de resumen y agrupación
La primera operación de resumen que puede surgir es “contar” filas. La función tally() devuelve el número de filas totales de un data.frame. La función count() proporciona también este número; si, además, se pasa como argumento alguna variable, lo que devuelve es el número de filas para cada valor diferente de dicha/s variable/s. Estos recuentos se pueden añadir a la tabla de datos con las funciones add_count() y add_tally(), lo que permite calcular frecuencias absolutas y relativas fácilmente.
pm10 |> tally()
#> n
#> 1 53794
pm10 |> count(zona)
#> zona n
#> 1: Interior M30 20690
#> 2: Noreste 12414
#> 3: Noroeste 4138
#> 4: Sureste 8276
#> 5: Suroeste 8276La función summarise() (o, equivalentemente, summarize()) aplica alguna función de resumen a la/s variable/s que se especifiquen (mean(), max(), etc.). El paquete dplyr tiene algunas funciones de resumen adicionales, como n() (número de filas), n_distinct() (número de filas con valores distintos) y first(), last(), nth() (primero, último y n-ésimo valor, en el orden en el que se encuentran, respectivamente).
En muchas ocasiones, las operaciones de análisis se realizan en grupos definidos por alguna variable de agrupación. La función group_by() “prepara” la tabla de datos para realizar operaciones de este tipo. Una vez agrupados los datos, se pueden añadir operaciones de resumen como las vistas anteriormente. A veces hay que “desagrupar” los datos, para lo que se utiliza la función ungroup().
A continuación, se muestra una expresión un poco más compleja que las anteriores. En el conjunto de datos contam_mad del paquete CDR, se filtra por el nombre de contaminante “NOx”. Después se agrupan los datos por zona y se calculan algunos estadísticos de resumen para cada zona.
contam_mad |>
filter(nom_abv == "NOx") |> # se filtra por N0x
group_by(zona) |>
summarize(
min = min(daily_mean, na.rm = TRUE),
q1 = quantile(daily_mean, 0.25, na.rm = TRUE),
median = median(daily_mean, na.rm = TRUE),
mean = mean(daily_mean, na.rm = TRUE),
q3 = quantile(daily_mean, 0.75, na.rm = TRUE),
max = max(daily_mean, na.rm = TRUE)
)
#> A tibble: 5 × 7
zona min q1 median mean q3 max
<chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 Interior M30 0.0833 32.4 54.1 72.9 90.0 759.
#> 2 Noreste 1 23.8 39.6 56.2 68.9 516.
#> 3 Noroeste 0 12.0 20.3 29.7 34.5 352.
#> 4 Sureste 0 29.1 45.4 64.6 77.2 453
#> 5 Suroeste 0.667 33.5 59.6 90.5 114. 666.3.6.3 Combinación de datos
En el apartado anterior se han tratado los “verbos” de una tabla. Es muy común que haya que combinar datos de distintas tablas, para lo cual se utilizan lo que el tidyverse considera two tables verbs. En esencia, para combinar tablas que contienen información relacionada, hay que saber cuáles son las columnas que se refieren a lo mismo, para hacer las uniones (joins) utilizando esas columnas. Hay cuatro tipos de uniones que se pueden realizar, usando las siguientes funciones:
inner_join(): se incluyen las filas de ambas tablas para las que coinciden las variables de unión.left_join(): se incluyen todas las filas de la primera tabla y solo las de la segunda donde hay coincidencias.right_join(): se incluyen todas las filas de la segunda tabla y solo las de la primera donde hay coincidencias.full_join(): se incluyen todas las filas de las dos tablas.
Las funciones requieren como argumentos dos tablas de datos y la especificación de las columnas coincidentes. Si no se especifica, hace las uniones por todas las columnas coincidentes en ambas tablas. Para las filas que solo están en una de las tablas, se añaden valores NA donde no haya coincidencias.
A modo de ejemplo, las siguientes expresiones unen dos datasets para combinar datos de municipios con su renta. En el Cap. 8 se verán estas uniones en la práctica.
library("sf")
munis_renta <- municipios |>
left_join(renta_municipio_data) |>
select(name, cpro, cmun, `2019`)
#> Joining, by = "codigo_ine"Otra forma de unir tablas es, simplemente, añadiendo columnas (que tengan el mismo número de filas) o filas (que tengan el mismo número de columnas). Para ello se usan las funciones bind_cols() y bind_rows(), respectivamente. Otra forma conveniente de añadir nuevas filas o columnas son las funciones add_row() y add_column(). Se pueden añadir antes o después de una fila/columna especificada con el argumento .before, y pasando los valores como pares “variable = valor” para cada variable en el conjunto de datos.
Como comentario final del paquete dplyr, una característica importante es que se pueden usar las funciones vistas sobre tablas de una base de datos, sin necesidad de utilizar sentencias SQL y con la ventaja de que las operaciones se realizan en el motor de la base de datos. En el Cap. 5 se tratarán las cuestiones relacionadas con los gestores de bases de datos y SQL.
3.6.4 Reorganización de datos
A lo largo del capítulo se ha visto la importancia de disponer los datos de forma rectangular, de forma que se tenga una columna para cada variable y una fila para cada observación. Algunas veces es conveniente reorganizar los datos más “a lo ancho” o más “a lo largo” de lo que se encuentran.
Para estas operaciones se utilizan las funciones pivot_longer() y pivot_wider() del paquete tidyr del tidyverse de la siguiente forma:
pivot_longer(): el argumentonames_toasigna el nombre de la nueva variable que va a indicar de qué columna vienen los datos; y el argumentovalues_toasigna el nombre de la nueva variable que va a contener el valor de la tabla original.pivot_wider(): el argumentonames_fromindica el nombre de la variable que contiene los nombres de las nuevas columnas a crear a lo ancho; y el argumentovalues_fromindica el nombre de la variable que contiene los valores en la tabla original. Las observaciones deben estar identificadas de forma única por varias variables. Si no es el caso, se puede aplicar una función al estilo de las tablas dinámicas de las hojas de cálculo con el argumentovalues_fn.
A modo de ejemplo, el conjunto de datos contam_mad tiene los datos “mezclados” de varias variables medioambientales en la columna daily_mean. La columna nom_abv contiene el parámetro al que se refiere la columna de datos. Entonces, interesa “extender” la tabla para tener cada parámetro en una columna, de forma que se pueda hacer un análisis de datos adecuado, como en el siguiente código:
library("tidyr")
extendida <- contam_mad |>
pivot_wider(names_from = "nom_abv",
values_from = "daily_mean",
values_fn = mean)
colnames(extendida)
#> [1] "estaciones" "id" "id_name" "longitud"
#> [5] "latitud" "nom_mag" "ud_med" "fecha"
#> [9] "zona" "tipo" "BEN" "SO2"
#> [13] "NO2" "EBE" "CO" "NO"
#> [17] "PM10" "PM2.5" "TOL" "NOx"Se deja como ejercicio volver a obtener la tabla original usando la función pivot_longer() a partir del objeto extendida.
El paquete tidyr también contiene funciones para reorganizar las columnas de la tabla uniendo columnas con la función unite(), o separando una columna en dos o más con la función separate() (véanse los detalles en la ayuda de las funciones).
Para terminar este apartado de reorganización de datos, se da una primera aproximación al tratamiento de valores perdidos, que se abordará en el Cap. 8. En R, un valor perdido se representa por el valor especial NA (not available). Brevemente, las funciones más utilizadas en este campo son:
drop_na()del paquetetidyr: permite eliminar las filas que tienen valores perdidos en ciertas variables (o en cualquiera, si no se especifica ninguna).replace_na(): sustituye los valores perdidos en cada variable por el valor especificado.fill(): permite “rellenar” valores perdidos con los últimos encontrados.
Los datos de contaminación a menudo tienen muchos valores perdidos. La siguiente expresión elimina las filas del conjunto de datos contam_mad con valores perdidos y, después, cuenta las filas.
contam_mad |>
drop_na() |> # se omiten los NAs para el análisis
count()
#> n
#> 1: 505773